process.env.NOTIFY_SOCKET = 'mocked';
const data = require('./stub/data');
const logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const MQTT = require('./stub/mqtt');
const path = require('path');
const settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises');
const tmp = require('tmp');
const mocksClear = [
    zigbeeHerdsman.permitJoin,
    MQTT.end,
    zigbeeHerdsman.stop,
    logger.debug,
    MQTT.publish,
    MQTT.connect,
    zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
    zigbeeHerdsman.devices.bulb.removeFromNetwork,
    logger.error,
];

const fs = require('fs');

const LOG_MQTT_NS = 'z2m:mqtt';

jest.mock(
    'sd-notify',
    () => {
        return {
            watchdogInterval: () => {
                return 3000;
            },
            startWatchdogMode: (interval) => {},
            stopWatchdogMode: () => {},
            ready: () => {},
            stopping: () => {},
        };
    },
    {virtual: true},
);

describe('Controller', () => {
    let controller;
    let mockExit;

    beforeAll(async () => {
        jest.useFakeTimers();
    });

    beforeEach(() => {
        MQTT.restoreOnMock();
        zigbeeHerdsman.returnDevices.splice(0);
        mockExit = jest.fn();
        data.writeDefaultConfiguration();
        settings.reRead();
        controller = new Controller(jest.fn(), mockExit);
        mocksClear.forEach((m) => m.mockClear());
        settings.reRead();
        data.writeDefaultState();
    });

    afterAll(async () => {
        jest.useRealTimers();
    });

    it('Start controller', async () => {
        settings.set(['advanced', 'transmit_power'], 14);
        await controller.start();
        expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({
            network: {
                panID: 6754,
                extendedPanID: [221, 221, 221, 221, 221, 221, 221, 221],
                channelList: [11],
                networkKey: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
            },
            databasePath: path.join(data.mockDir, 'database.db'),
            databaseBackupPath: path.join(data.mockDir, 'database.db.backup'),
            backupPath: path.join(data.mockDir, 'coordinator_backup.json'),
            acceptJoiningDeviceHandler: expect.any(Function),
            adapter: {concurrent: undefined, delay: undefined, disableLED: false, transmitPower: 14},
            serialPort: {baudRate: undefined, rtscts: undefined, path: '/dev/dummy'},
        });
        expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(0);
        expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined);
        expect(logger.info).toHaveBeenCalledWith(`Currently ${Object.values(zigbeeHerdsman.devices).length - 1} devices are joined.`);
        expect(logger.info).toHaveBeenCalledWith(
            'bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)',
        );
        expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
        expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
        expect(MQTT.connect).toHaveBeenCalledTimes(1);
        expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', {
            will: {payload: Buffer.from('offline'), retain: true, topic: 'zigbee2mqtt/bridge/state', qos: 1},
        });
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
            {retain: true, qos: 0},
            expect.any(Function),
        );
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({brightness: 255}), {retain: true, qos: 0}, expect.any(Function));
    });

    it('Start controller when permit join fails', async () => {
        zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {
            throw new Error('failed!');
        });
        await controller.start();
        expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
        expect(MQTT.connect).toHaveBeenCalledTimes(1);
    });

    it('Start controller with specific MQTT settings', async () => {
        const ca = tmp.fileSync().name;
        fs.writeFileSync(ca, 'ca');
        const key = tmp.fileSync().name;
        fs.writeFileSync(key, 'key');
        const cert = tmp.fileSync().name;
        fs.writeFileSync(cert, 'cert');

        const configuration = {
            base_topic: 'zigbee2mqtt',
            server: 'mqtt://localhost',
            keepalive: 30,
            ca,
            cert,
            key,
            password: 'pass',
            user: 'user1',
            client_id: 'my_client_id',
            reject_unauthorized: false,
            version: 5,
        };
        settings.set(['mqtt'], configuration);
        await controller.start();
        await flushPromises();
        expect(MQTT.connect).toHaveBeenCalledTimes(1);
        const expected = {
            will: {payload: Buffer.from('offline'), retain: true, topic: 'zigbee2mqtt/bridge/state', qos: 1},
            keepalive: 30,
            ca: Buffer.from([99, 97]),
            key: Buffer.from([107, 101, 121]),
            cert: Buffer.from([99, 101, 114, 116]),
            password: 'pass',
            username: 'user1',
            clientId: 'my_client_id',
            rejectUnauthorized: false,
            protocolVersion: 5,
        };
        expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', expected);
    });

    it('Should generate network_key, pan_id and ext_pan_id when set to GENERATE', async () => {
        settings.set(['advanced', 'network_key'], 'GENERATE');
        settings.set(['advanced', 'pan_id'], 'GENERATE');
        settings.set(['advanced', 'ext_pan_id'], 'GENERATE');
        await controller.start();
        await flushPromises();
        expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.networkKey.length).toStrictEqual(16);
        expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.extendedPanID.length).toStrictEqual(8);
        expect(zigbeeHerdsman.constructor.mock.calls[0][0].network.panID).toStrictEqual(expect.any(Number));
        expect(data.read().advanced.network_key.length).toStrictEqual(16);
        expect(data.read().advanced.ext_pan_id.length).toStrictEqual(8);
        expect(data.read().advanced.pan_id).toStrictEqual(expect.any(Number));
    });

    it('Start controller should publish cached states', async () => {
        data.writeDefaultState();
        await controller.start();
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({brightness: 255}), {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {qos: 0, retain: false}, expect.any(Function));
    });

    it('Start controller should not publish cached states when disabled', async () => {
        settings.set(['advanced', 'cache_state_send_on_startup'], false);
        data.writeDefaultState();
        await controller.start();
        await flushPromises();
        const publishedTopics = MQTT.publish.mock.calls.map((m) => m[0]);
        expect(publishedTopics).toEqual(expect.not.arrayContaining(['zigbee2mqtt/bulb', 'zigbee2mqtt/remote']));
    });

    it('Start controller should not publish cached states when cache_state is false', async () => {
        settings.set(['advanced', 'cache_state'], false);
        data.writeDefaultState();
        await controller.start();
        await flushPromises();
        expect(MQTT.publish).not.toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            `{"state":"ON","brightness":50,"color_temp":370,"linkquality":99}`,
            {qos: 0, retain: true},
            expect.any(Function),
        );
        expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/remote', `{"brightness":255}`, {qos: 0, retain: true}, expect.any(Function));
    });

    it('Log when MQTT client is unavailable', async () => {
        await controller.start();
        await flushPromises();
        logger.error.mockClear();
        controller.mqtt.client.reconnecting = true;
        jest.advanceTimersByTime(11 * 1000);
        expect(logger.error).toHaveBeenCalledWith('Not connected to MQTT server!');
        controller.mqtt.client.reconnecting = false;
    });

    it('Dont publish to mqtt when client is unavailable', async () => {
        await controller.start();
        await flushPromises();
        logger.error.mockClear();
        controller.mqtt.client.reconnecting = true;
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {
            state: 'ON',
            brightness: 50,
            color_temp: 370,
            color: {r: 100, g: 50, b: 10},
            dummy: {1: 'yes', 2: 'no'},
        });
        await flushPromises();
        expect(logger.error).toHaveBeenCalledTimes(2);
        expect(logger.error).toHaveBeenCalledWith('Not connected to MQTT server!');
        expect(logger.error).toHaveBeenCalledWith(
            'Cannot send message: topic: \'zigbee2mqtt/bulb\', payload: \'{"brightness":50,"color":{"b":10,"g":50,"r":100},"color_temp":370,"dummy":{"1":"yes","2":"no"},"linkquality":99,"state":"ON"}',
        );
        controller.mqtt.client.reconnecting = false;
    });

    it('Load empty state when state file does not exist', async () => {
        data.removeState();
        await controller.start();
        await flushPromises();
        expect(controller.state.state).toStrictEqual({});
    });

    it('Should remove device not on passlist on startup', async () => {
        settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
        zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {
            throw new Error('dummy');
        });
        await controller.start();
        await flushPromises();
        expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
        expect(zigbeeHerdsman.devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(1);
    });

    it('Should remove device on blocklist on startup', async () => {
        settings.set(['blocklist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
        await controller.start();
        await flushPromises();
        expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.devices.bulb.removeFromNetwork).toHaveBeenCalledTimes(0);
    });

    it('Start controller fails', async () => {
        zigbeeHerdsman.start.mockImplementationOnce(() => {
            throw new Error('failed');
        });
        await controller.start();
        expect(mockExit).toHaveBeenCalledTimes(1);
    });

    it('Start controller fails due to MQTT', async () => {
        MQTT.on.mockImplementation((type, handler) => {
            if (type === 'error') handler({message: 'addr not found'});
        });
        await controller.start();
        await flushPromises();
        expect(logger.error).toHaveBeenCalledWith('MQTT error: addr not found');
        expect(logger.error).toHaveBeenCalledWith('MQTT failed to connect, exiting... (addr not found)');
        expect(mockExit).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledWith(1, false);
    });

    it('Start controller with permit join true', async () => {
        settings.set(['permit_join'], false);
        await controller.start();
        expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false, undefined, undefined);
    });

    it('Start controller and stop with restart', async () => {
        await controller.start();
        await controller.stop(true);
        expect(MQTT.end).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledWith(0, true);
    });

    it('Start controller and stop', async () => {
        zigbeeHerdsman.stop.mockImplementationOnce(() => {
            throw new Error('failed');
        });
        await controller.start();
        await controller.stop();
        expect(MQTT.end).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledWith(1, false);
    });

    it('Start controller adapter disconnects', async () => {
        zigbeeHerdsman.stop.mockImplementationOnce(() => {
            throw new Error('failed');
        });
        await controller.start();
        await zigbeeHerdsman.events.adapterDisconnected();
        await flushPromises();
        expect(MQTT.end).toHaveBeenCalledTimes(1);
        expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledTimes(1);
        expect(mockExit).toHaveBeenCalledWith(1, false);
    });

    it('Handle mqtt message', async () => {
        const eventbus = controller.eventBus;
        let spyEventbusEmitMQTTMessage = jest.spyOn(eventbus, 'emitMQTTMessage').mockImplementation();

        await controller.start();
        logger.debug.mockClear();
        await MQTT.events.message('dummytopic', 'dummymessage');
        expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: 'dummytopic', message: 'dummymessage'});
        expect(logger.log).toHaveBeenCalledWith('debug', "Received MQTT message on 'dummytopic' with data 'dummymessage'", LOG_MQTT_NS);
    });

    it('Skip MQTT messages on topic we published to', async () => {
        const eventbus = controller.eventBus;
        let spyEventbusEmitMQTTMessage = jest.spyOn(eventbus, 'emitMQTTMessage').mockImplementation();

        await controller.start();
        logger.debug.mockClear();
        await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
        expect(spyEventbusEmitMQTTMessage).toHaveBeenCalledWith({topic: 'zigbee2mqtt/skip-this-topic', message: 'skipped'});
        logger.debug.mockClear();
        await controller.mqtt.publish('skip-this-topic', '', {});
        await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
        expect(logger.debug).toHaveBeenCalledTimes(0);
    });

    it('On zigbee event message', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {
            device,
            endpoint: device.getEndpoint(1),
            type: 'attributeReport',
            linkquality: 10,
            cluster: 'genBasic',
            data: {modelId: device.modelID},
        };
        await zigbeeHerdsman.events.message(payload);
        await flushPromises();
        expect(logger.log).toHaveBeenCalledWith(
            'debug',
            `Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1`,
            'z2m',
        );
    });

    it('On zigbee event message with group ID', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {
            device,
            endpoint: device.getEndpoint(1),
            type: 'attributeReport',
            linkquality: 10,
            groupID: 0,
            cluster: 'genBasic',
            data: {modelId: device.modelID},
        };
        await zigbeeHerdsman.events.message(payload);
        await flushPromises();
        expect(logger.log).toHaveBeenCalledWith(
            'debug',
            `Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1 with groupID 0`,
            'z2m',
        );
    });

    it('Should add entities which are missing from configuration but are in database to configuration', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.notInSettings;
        expect(settings.getDevice(device.ieeeAddr)).not.toBeUndefined();
    });

    it('On zigbee deviceJoined', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device};
        await zigbeeHerdsman.events.deviceJoined(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'device_connected', message: {friendly_name: 'bulb'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('acceptJoiningDeviceHandler reject device on blocklist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        settings.set(['blocklist'], [device.ieeeAddr]);
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(false);
    });

    it('acceptJoiningDeviceHandler accept device not on blocklist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        settings.set(['blocklist'], ['123']);
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(true);
    });

    it('acceptJoiningDeviceHandler accept device on passlist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        settings.set(['passlist'], [device.ieeeAddr]);
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(true);
    });

    it('acceptJoiningDeviceHandler reject device not in passlist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        settings.set(['passlist'], ['123']);
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(false);
    });

    it('acceptJoiningDeviceHandler should prefer passlist above blocklist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        settings.set(['passlist'], [device.ieeeAddr]);
        settings.set(['blocklist'], [device.ieeeAddr]);
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(true);
    });

    it('acceptJoiningDeviceHandler accept when not on blocklist and passlist', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const handler = zigbeeHerdsman.constructor.mock.calls[0][0].acceptJoiningDeviceHandler;
        expect(await handler(device.ieeeAddr)).toBe(true);
    });

    it('Shouldnt crash when two device join events are received', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device};
        zigbeeHerdsman.events.deviceJoined(payload);
        zigbeeHerdsman.events.deviceJoined(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'device_connected', message: {friendly_name: 'bulb'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee deviceInterview started', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device, status: 'started'};
        await zigbeeHerdsman.events.deviceInterview(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'pairing', message: 'interview_started', meta: {friendly_name: 'bulb'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee deviceInterview failed', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device, status: 'failed'};
        await zigbeeHerdsman.events.deviceInterview(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'pairing', message: 'interview_failed', meta: {friendly_name: 'bulb'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee deviceInterview successful supported', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device, status: 'successful'};
        await zigbeeHerdsman.events.deviceInterview(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({
                type: 'pairing',
                message: 'interview_successful',
                meta: {
                    friendly_name: 'bulb',
                    model: 'LED1545G12',
                    vendor: 'IKEA',
                    description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
                    supported: true,
                },
            }),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee deviceInterview successful not supported', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.unsupported;
        const payload = {device, status: 'successful'};
        await zigbeeHerdsman.events.deviceInterview(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'pairing', message: 'interview_successful', meta: {friendly_name: '0x0017880104e45518', supported: false}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee event device announce', async () => {
        await controller.start();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {device};
        await zigbeeHerdsman.events.deviceAnnounce(payload);
        await flushPromises();
        expect(logger.debug).toHaveBeenCalledWith(`Device 'bulb' announced itself`);
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'device_announced', message: 'announce', meta: {friendly_name: 'bulb'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee event device leave (removed from database and settings)', async () => {
        await controller.start();
        zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
        settings.set(['devices'], {});
        MQTT.publish.mockClear();
        const device = zigbeeHerdsman.devices.bulb;
        const payload = {ieeeAddr: device.ieeeAddr};
        await zigbeeHerdsman.events.deviceLeave(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'device_removed', message: 'left_network', meta: {friendly_name: '0x000b57fffec6a5b2'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('On zigbee event device leave (removed from database and NOT settings)', async () => {
        await controller.start();
        zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
        const device = zigbeeHerdsman.devices.bulb;
        MQTT.publish.mockClear();
        const payload = {ieeeAddr: device.ieeeAddr};
        await zigbeeHerdsman.events.deviceLeave(payload);
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bridge/log',
            stringify({type: 'device_removed', message: 'left_network', meta: {friendly_name: '0x000b57fffec6a5b2'}}),
            {retain: false, qos: 0},
            expect.any(Function),
        );
    });

    it('Publish entity state attribute output', async () => {
        await controller.start();
        settings.set(['experimental', 'output'], 'attribute');
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {
            dummy: {1: 'yes', 2: 'no'},
            color: {r: 100, g: 50, b: 10},
            state: 'ON',
            test: undefined,
            test1: null,
            color_temp: 370,
            brightness: 50,
        });
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '50', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color_temp', '370', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color', '100,50,10', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/dummy-1', 'yes', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/dummy-2', 'no', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/test1', '', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/test', '', {qos: 0, retain: true}, expect.any(Function));
    });

    it('Publish entity state attribute_json output', async () => {
        await controller.start();
        settings.set(['experimental', 'output'], 'attribute_and_json');
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(5);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color_temp', '370', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '99', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 99}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Publish entity state attribute_json output filtered', async () => {
        await controller.start();
        settings.set(['experimental', 'output'], 'attribute_and_json');
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']);
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(3);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Publish entity state attribute_json output filtered (device_options)', async () => {
        await controller.start();
        settings.set(['experimental', 'output'], 'attribute_and_json');
        settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']);
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(3);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Publish entity state attribute_json output filtered cache', async () => {
        await controller.start();
        settings.set(['advanced', 'output'], 'attribute_and_json');
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'filtered_cache'], ['linkquality']);
        MQTT.publish.mockClear();

        const device = controller.zigbee.resolveEntity('bulb');
        expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});

        await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
        await flushPromises();

        expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
        expect(MQTT.publish).toHaveBeenCalledTimes(5);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '87', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Publish entity state attribute_json output filtered cache (device_options)', async () => {
        await controller.start();
        settings.set(['advanced', 'output'], 'attribute_and_json');
        settings.set(['device_options', 'filtered_cache'], ['linkquality']);
        MQTT.publish.mockClear();

        const device = controller.zigbee.resolveEntity('bulb');
        expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});

        await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
        await flushPromises();

        expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
        expect(MQTT.publish).toHaveBeenCalledTimes(5);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '87', {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Publish entity state with device information', async () => {
        await controller.start();
        settings.set(['mqtt', 'include_device_information'], true);
        MQTT.publish.mockClear();
        let device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({
                state: 'ON',
                brightness: 50,
                color_temp: 370,
                linkquality: 99,
                device: {
                    friendlyName: 'bulb',
                    model: 'LED1545G12',
                    ieeeAddr: '0x000b57fffec6a5b2',
                    networkAddress: 40369,
                    type: 'Router',
                    manufacturerID: 4476,
                    powerSource: 'Mains (single phase)',
                    dateCode: null,
                    softwareBuildID: null,
                },
            }),
            {qos: 0, retain: true},
            expect.any(Function),
        );

        // Unsupported device should have model "unknown"
        device = controller.zigbee.resolveEntity('unsupported2');
        await controller.publishEntityState(device, {state: 'ON'});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/unsupported2',
            stringify({
                state: 'ON',
                device: {
                    friendlyName: 'unsupported2',
                    model: 'notSupportedModelID',
                    ieeeAddr: '0x0017880104e45529',
                    networkAddress: 6536,
                    type: 'EndDevice',
                    manufacturerID: 0,
                    powerSource: 'Battery',
                    dateCode: null,
                    softwareBuildID: null,
                },
            }),
            {qos: 0, retain: false},
            expect.any(Function),
        );
    });

    it('Should publish entity state without retain', async () => {
        await controller.start();
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], false);
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
            {qos: 0, retain: false},
            expect.any(Function),
        );
    });

    it('Should publish entity state with retain', async () => {
        await controller.start();
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Should publish entity state with expiring retention', async () => {
        await controller.start();
        settings.set(['mqtt', 'version'], 5);
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retain'], true);
        settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'retention'], 37);
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
            {qos: 0, retain: true, properties: {messageExpiryInterval: 37}},
            expect.any(Function),
        );
    });

    it('Publish entity state no empty messages', async () => {
        data.writeEmptyState();
        await controller.start();
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(0);
    });

    it('Should allow to disable state persistency', async () => {
        settings.set(['advanced', 'cache_state_persistent'], false);
        data.removeState();
        await controller.start();
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await controller.publishEntityState(device, {brightness: 200});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(2);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'ON'}), {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/bulb',
            stringify({state: 'ON', brightness: 200}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
        await controller.stop();
        expect(data.stateExists()).toBeFalsy();
    });

    it('Shouldnt crash when it cannot save state', async () => {
        data.removeState();
        await controller.start();
        logger.error.mockClear();
        controller.state.file = '/';
        await controller.state.save();
        expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to write state to \'\/\'/));
    });

    it('Publish should not cache when set', async () => {
        settings.set(['advanced', 'cache_state'], false);
        data.writeEmptyState();
        await controller.start();
        MQTT.publish.mockClear();
        const device = controller.zigbee.resolveEntity('bulb');
        await controller.publishEntityState(device, {state: 'ON'});
        await controller.publishEntityState(device, {brightness: 200});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(2);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'ON'}), {qos: 0, retain: true}, expect.any(Function));
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({brightness: 200}), {qos: 0, retain: true}, expect.any(Function));
    });

    it('Should start when state is corrupted', async () => {
        fs.writeFileSync(path.join(data.mockDir, 'state.json'), 'corrupted');
        await controller.start();
        await flushPromises();
        expect(controller.state.state).toStrictEqual({});
    });

    it('Start controller with force_disable_retain', async () => {
        settings.set(['mqtt', 'force_disable_retain'], true);
        await controller.start();
        await flushPromises();
        expect(MQTT.connect).toHaveBeenCalledTimes(1);
        const expected = {
            will: {payload: Buffer.from('offline'), retain: false, topic: 'zigbee2mqtt/bridge/state', qos: 1},
        };
        expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', expected);
    });

    it('Should republish retained messages on MQTT reconnect', async () => {
        await controller.start();
        MQTT.publish.mockClear();
        MQTT.events['connect']();
        await jest.advanceTimersByTimeAsync(2500); // before any startup configure triggers
        expect(MQTT.publish).toHaveBeenCalledTimes(14);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
    });

    it('Should not republish retained messages on MQTT reconnect when retained message are sent', async () => {
        await controller.start();
        MQTT.publish.mockClear();
        MQTT.events['connect']();
        await flushPromises();
        await MQTT.events.message('zigbee2mqtt/bridge/info', 'dummy');
        await jest.advanceTimersByTimeAsync(2500); // before any startup configure triggers
        expect(MQTT.publish).toHaveBeenCalledTimes(1);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/state', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
    });

    it('Should prevent any message being published with retain flag when force_disable_retain is set', async () => {
        settings.set(['mqtt', 'force_disable_retain'], true);
        await controller.mqtt.connect();
        MQTT.publish.mockClear();
        await controller.mqtt.publish('fo', 'bar', {retain: true});
        await flushPromises();
        expect(MQTT.publish).toHaveBeenCalledTimes(1);
        expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', {retain: false, qos: 0}, expect.any(Function));
    });

    it('Should disable legacy options on new network start', async () => {
        settings.set(['homeassistant'], true);
        settings.reRead();
        expect(settings.get().homeassistant.legacy_entity_attributes).toBeTruthy();
        expect(settings.get().advanced.legacy_api).toBeTruthy();
        zigbeeHerdsman.start.mockReturnValueOnce('reset');
        await controller.start();
        expect(settings.get().homeassistant.legacy_entity_attributes).toBeFalsy();
        expect(settings.get().advanced.legacy_api).toBeFalsy();
    });

    it('Should publish last seen changes', async () => {
        settings.set(['advanced', 'last_seen'], 'epoch');
        await controller.start();
        await flushPromises();
        MQTT.publish.mockClear();
        const device = zigbeeHerdsman.devices.remote;
        await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'});
        expect(MQTT.publish).toHaveBeenCalledTimes(1);
        expect(MQTT.publish).toHaveBeenCalledWith(
            'zigbee2mqtt/remote',
            stringify({brightness: 255, last_seen: 1000}),
            {qos: 0, retain: true},
            expect.any(Function),
        );
    });

    it('Should not publish last seen changes when reason is messageEmitted', async () => {
        settings.set(['advanced', 'last_seen'], 'epoch');
        await controller.start();
        await flushPromises();
        MQTT.publish.mockClear();
        const device = zigbeeHerdsman.devices.remote;
        await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'messageEmitted'});
        expect(MQTT.publish).toHaveBeenCalledTimes(0);
    });

    it('Ignore messages from coordinator', async () => {
        // https://github.com/Koenkk/zigbee2mqtt/issues/9218
        await controller.start();
        const device = zigbeeHerdsman.devices.coordinator;
        const payload = {
            device,
            endpoint: device.getEndpoint(1),
            type: 'attributeReport',
            linkquality: 10,
            cluster: 'genBasic',
            data: {modelId: device.modelID},
        };
        await zigbeeHerdsman.events.message(payload);
        await flushPromises();
        expect(logger.log).toHaveBeenCalledWith(
            'debug',
            `Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`,
            'z2m',
        );
    });

    it('Should remove state of removed device when stopped', async () => {
        await controller.start();
        const device = controller.zigbee.resolveEntity('bulb');
        expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
        device.zh.isDeleted = true;
        await controller.stop();
        expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined);
    });

    it('EventBus should handle errors', async () => {
        const eventbus = controller.eventBus;
        const callback = jest.fn().mockImplementation(async () => {
            throw new Error('Whoops!');
        });
        eventbus.onStateChange('test', callback);
        eventbus.emitStateChange({});
        await flushPromises();
        expect(callback).toHaveBeenCalledTimes(1);
        expect(logger.error).toHaveBeenCalledWith(`EventBus error 'String/stateChange': Whoops!`);
    });
});
